Skip to content

gRPC Support for Wolverine HTTP Endpoints + IMessageBus.StreamAsync<T>#2525

Merged
jeremydmiller merged 17 commits intoJasperFx:mainfrom
erikshafer:feature/grpc-and-streaming-support
Apr 20, 2026
Merged

gRPC Support for Wolverine HTTP Endpoints + IMessageBus.StreamAsync<T>#2525
jeremydmiller merged 17 commits intoJasperFx:mainfrom
erikshafer:feature/grpc-and-streaming-support

Conversation

@erikshafer
Copy link
Copy Markdown
Contributor

@erikshafer erikshafer commented Apr 17, 2026

feat: gRPC services + typed clients + IMessageBus.StreamAsync<T> + rich error details

Adds a new WolverineFx.Grpc package that exposes Wolverine handlers as
ASP.NET Core gRPC services, plus a first-class IMessageBus.StreamAsync<T>
overload so handlers can return IAsyncEnumerable<T>. The same handler can now
back a REST endpoint, an async message, and a gRPC call with no duplication.
Also ships an opt-in google.rpc.Status rich-error pipeline (the gRPC
counterpart to ProblemDetails) with a separate WolverineFx.FluentValidation.Grpc
bridge package, and a Wolverine-flavored typed gRPC client
(AddWolverineGrpcClient<T>()) that stamps envelope headers on outgoing calls
and translates RpcException → typed .NET exceptions — closing the loop so a
Wolverine → Wolverine gRPC hop round-trips correlation-id / tenant-id and
typed failures with zero user plumbing.

Branch: feature/grpc-and-streaming-support
Target: main
Scope: 16 commits, +8444 / −9 across 154 files. 78 tests green in Wolverine.Grpc.Tests.


Why

gRPC is a recurring ask for teams already running Wolverine as their in-process
mediator — they want the same handlers addressable over a strongly-typed wire
protocol, especially for streaming. The alternative (hand-rolling gRPC stubs
that forward to IMessageBus) isn't hard, but it's boilerplate at every method
and duplicates the exception mapping, discovery, and codegen story Wolverine
already owns for HTTP.

This PR makes gRPC a peer of Wolverine.Http: handler-first, convention-driven,
codegen-participating, and observability-correct out of the box.


What's in the package

IMessageBus.StreamAsync<T> (M2)

A new overload on IMessageBus:

IAsyncEnumerable<T> StreamAsync<T>(object request, CancellationToken ct = default);

Any handler that returns IAsyncEnumerable<T> is routed to it. The executor,
tracing executor, message context, and routing layers all grew the matching
method so the streaming path shares the full Wolverine pipeline (activities,
middleware, error handling) with unary InvokeAsync<T>.

Files: Wolverine/IMessageBus.cs, Runtime/MessageBus.cs,
Runtime/MessageContext.cs, Runtime/Handlers/{Executor,TracingExecutor,NoHandlerExecutor}.cs,
Runtime/Routing/{IMessageInvoker,MessageRoute,TopicRouting}.cs,
Runtime/WolverineRuntime.cs, Runtime/WolverineTracing.cs,
TestMessageContext.cs, Transports/Sending/SendingEnvelopeLifecycle.cs.

Code-first services (M3)

WolverineGrpcServiceBase gives you an injected IMessageBus Bus; the method
body is a one-liner to Bus.InvokeAsync<T> or Bus.StreamAsync<T>.

public class PingGrpcService : WolverineGrpcServiceBase, IPingService
{
    public PingGrpcService(IMessageBus bus) : base(bus) { }

    public Task<PongReply> Ping(PingRequest request, CallContext context = default)
        => Bus.InvokeAsync<PongReply>(request, context.CancellationToken);
}

Discovery: any class whose name ends in GrpcService, or carries
[WolverineGrpcService], is picked up by MapWolverineGrpcServices().

Proto-first services (M4)

Ship a .proto, let Grpc.Tools generate the stub, mark an abstract subclass
with [WolverineGrpcService] — Wolverine generates a concrete wrapper named
{ProtoServiceName}GrpcHandler and forwards each RPC to IMessageBus.

[WolverineGrpcService]
public abstract class GreeterGrpcService : Greeter.GreeterBase;
  • The generated type participates in the same codegen pipeline as handler/HTTP
    chains (GrpcGraph implements ICodeFileCollectionWithServices +
    IDescribeMyself) — it shows up in dotnet run -- describe / describe-routing.
  • Supports both unary and server-streaming RPCs.
  • Fail-fast diagnostics: a concrete proto stub with [WolverineGrpcService]
    throws InvalidOperationException at startup with the offending type name and
    a pointer to its proto base. Client-streaming and bidirectional shapes also
    fail fast with a clear message rather than silently skipping.

Files: GrpcGraph.cs, GrpcServiceChain.cs, WolverineGrpcServiceAttribute.cs,
ModifyGrpcServiceChainAttribute.cs, WolverineGrpcExtensions.cs.

Exception mapping (AIP-193, M5)

WolverineGrpcExceptionInterceptor is registered automatically by
AddWolverineGrpc(). Ordinary .NET exceptions map to the canonical gRPC status
code per google.aip.dev/193:

Exception Status
OperationCanceledException Cancelled
TimeoutException DeadlineExceeded
ArgumentException (+subclasses) InvalidArgument
KeyNotFoundException / FileNotFoundException / DirectoryNotFoundException NotFound
UnauthorizedAccessException PermissionDenied
InvalidOperationException FailedPrecondition
NotImplementedException / NotSupportedException Unimplemented
RpcException preserved as-is
anything else Internal

WolverineGrpcExceptionMapper.Map(ex) is public for reuse in user interceptors.

Rich error details (M11)

Opt-in AIP-193 google.rpc.Status payloads packed
into the grpc-status-details-bin trailer — the gRPC counterpart to HTTP's
ProblemDetails / ValidationProblemDetails. Off unless the user explicitly
calls UseGrpcRichErrorDetails(); when off, WolverineGrpcExceptionInterceptor
falls through to the canonical M5 table exactly as before.

Design choices, all deliberately mirroring existing Wolverine idioms:

  • Marker-singleton idempotency. UseGrpcRichErrorDetails guards with a
    WolverineGrpcRichDetailsMarker DI registration, the same pattern
    UseFluentValidation uses. Calling it twice is a no-op; calling it from a
    library extension is safe.
  • Method-on-config builder. UseGrpcRichErrorDetails(cfg => cfg.MapException<T>(...).EnableDefaultErrorInfo())
    follows the 2026 Wolverine convention for opts-scoped configuration (vs. a
    singleton WolverineGrpcOptions sidecar). The builder is ephemeral — it
    decomposes into IServiceCollection registrations when the extension returns.
  • Interceptor stays singleton. Providers are resolved per-request off
    ServerCallContext.GetHttpContext().RequestServices, so custom providers with
    scoped dependencies work the same as any ASP.NET Core service. No reflection
    on the exception type — providers claim exceptions via CanHandle.
  • Exception-to-payload seam separate from status-building.
    IValidationFailureAdapter (library-specific exception → FieldViolations)
    feeds a single ValidationExceptionStatusDetailsProvider that owns the
    BadRequest packing. That keeps the bridge package surface tiny (one class)
    and lets a future DataAnnotations bridge plug in without touching core.
  • DefaultErrorInfoProvider is opt-in and opaque. Emits
    ErrorInfo { Reason = exception.GetType().Name, Domain = "wolverine.grpc" }
    no messages, no stack traces. Safe to turn on in production.

Core seam (stays in Wolverine.Grpc):

public interface IGrpcStatusDetailsProvider
{
    Status? BuildStatus(Exception exception, ServerCallContext context);
}

Built-in providers: ValidationExceptionStatusDetailsProvider (chains
IValidationFailureAdapters), InlineStatusDetailsProvider<TException> (backs
MapException<T>), DefaultErrorInfoProvider (opt-in catch-all).

Files: IGrpcStatusDetailsProvider.cs, IValidationFailureAdapter.cs,
ValidationExceptionStatusDetailsProvider.cs, DefaultErrorInfoProvider.cs,
GrpcRichErrorDetailsConfiguration.cs, GrpcRichErrorDetailsExtensions.cs,
and a TryBuildRichStatus branch added to
WolverineGrpcExceptionInterceptor.cs.

Wolverine.FluentValidation.Grpc bridge package (M11)

The FluentValidation ↔ BadRequest adapter ships in a separate package so
hosts that don't use FluentValidation never pull the dependency — mirroring the
Wolverine.Http.FluentValidation / Wolverine.Http split on the HTTP side.

opts.UseFluentValidation();                    // existing
opts.UseGrpcRichErrorDetails();                // new, in Wolverine.Grpc
opts.UseFluentValidationGrpcErrorDetails();    // new, bridge package

Contents (three small files):

  • FluentValidationFailureAdapter — claims FluentValidation.ValidationException,
    maps ValidationFailure.PropertyName → BadRequest.FieldViolation.Field and
    ErrorMessage → Description.
  • WolverineFluentValidationGrpcMarker + UseFluentValidationGrpcErrorDetails
    marker-guarded idempotent opt-in.

No reflection, no exception sniffing in core — the adapter is the plug.

Files: src/Extensions/Wolverine.FluentValidation.Grpc/* (three source files +
csproj, PackageId WolverineFx.FluentValidation.Grpc).

Observability (M6)

The gRPC adapter preserves Activity.Current across the gRPC → Wolverine
boundary, so every handler activity chains under the ASP.NET Core hosting
activity (Microsoft.AspNetCore.Hosting.HttpRequestIn) under a single TraceId.
Users register the "Wolverine" ActivitySource on their OTel pipeline and
get end-to-end traces for free.

Known testability gap (documented): Microsoft.AspNetCore.TestHost.TestServer
bypasses real HTTP/2, so client→server traceparent header propagation isn't
observable via the in-memory fixture. The OTel tests in this PR assert only the
server-side chain Wolverine actually owns; the doc guide calls out
WebApplicationFactory with a loopback port as the right tool for end-to-end
propagation assertions.

Codegen polish (M6)

  • GrpcServiceChain.DiscoverSupportedMethods sorts method list by
    string.CompareOrdinal — reflection's GetMethods() order is unspecified,
    and byte-stable generated source keeps diffs clean across runs. Regression
    test: discovered_methods_are_sorted_alphabetically_for_byte_stable_codegen.
  • ForwardUnaryToMessageBusFrame.ParameterName(parameters, i) replaces raw
    parameters[i].Name! so a stripped-metadata assembly produces a readable
    argN fallback instead of an NRE.

Diagnostics CLI — codegen-preview --grpc (M12)

wolverine-diagnostics codegen-preview gains a --grpc / -g flag alongside
the existing --handler / -h and --route / -r, so proto-first gRPC
service wrappers can be inspected one at a time without dumping the whole
codegen output. Accepts the proto service name (Greeter), the stub class
name (GreeterGrpcService), or the generated file name (GreeterGrpcHandler).

dotnet run -- wolverine-diagnostics codegen-preview --grpc Greeter
dotnet run -- wolverine-diagnostics codegen-preview -g GreeterGrpcService

Implementation walks services.GetServices<ICodeFileCollection>() — the same
DI seam HTTP uses — so Wolverine.Grpc keeps zero compile-time coupling to
core Wolverine and the command stays symmetric across the three entry-point
styles. Covered end-to-end by codegen_preview_grpc_tests in
Wolverine.Grpc.Tests, plus helper-level string tests for GrpcInputToFileName
in CoreTests.

Middleware scoping — MiddlewareScoping.Grpc (M13)

The existing MiddlewareScoping enum (Anywhere, MessageHandlers, HttpEndpoints)
grows a new Grpc value, and GrpcServiceChain.Scoping changes from
MessageHandlers to Grpc. This is a behavior correction: previously, any
attribute scoped to MessageHandlers — e.g. [WolverineBefore(MiddlewareScoping.MessageHandlers)]
silently over-attached to gRPC service chains because those chains reported themselves
as message handlers. With this change:

  • [WolverineBefore(MiddlewareScoping.Grpc)] becomes available for gRPC-only
    cross-cutting concerns and attaches exclusively to gRPC chains.
  • [WolverineBefore(MiddlewareScoping.MessageHandlers)] now stays out of gRPC chains.
  • [WolverineBefore(MiddlewareScoping.Anywhere)] (the default) still applies everywhere.

The new enum value is appended last so existing ordinals (MessageHandlers = 1,
HttpEndpoints = 2) are preserved — no reinterpretation risk for any serialized or
reflected attribute value. ChainExtensions.MatchesScope uses direct equality after
an Anywhere short-circuit, so the additional enum value is fully transparent to
all existing consumers.

Covered by grpc_middleware_scoping_tests in Wolverine.Grpc.Tests (4 tests —
scoping value assertion, Grpc-scoped applies, Anywhere still applies,
MessageHandlers no longer applies against a real GrpcServiceChain discovered
from the Greeter proto-first sample).

Files: src/Wolverine/Attributes/HandlerMethodAttributes.cs,
src/Wolverine.Grpc/GrpcServiceChain.cs.

Typed gRPC client — AddWolverineGrpcClient<T>() (M14)

Promoted from the "tentative roadmap" bullet into shipping scope. A thin
Wolverine wrapper over Grpc.Net.ClientFactory.AddGrpcClient<T>() that closes
the symmetry with the server-side interceptors: envelope identity headers flow
outbound and RpcExceptions come back as typed .NET exceptions.

builder.Services.AddWolverineGrpcClient<IInventoryService>(o =>
{
    o.Address = new Uri("http://localhost:5007");
});

Three conveniences layered on top of the Microsoft client factory — no
replacement, only additive:

  1. WolverineGrpcClientPropagationInterceptor — stamps correlation-id,
    tenant-id, parent-id, conversation-id, message-id on outgoing calls
    when an IMessageContext is resolvable from the current DI scope. The wire
    vocabulary matches EnvelopeConstants — the same kebab-case keys every
    other Wolverine transport uses via EnvelopeMapper<TIncoming,TOutgoing>.
    Silently no-ops when no IMessageContext is in scope (bare Program.cs
    callers) and never overwrites a header the caller already set on the
    per-call Metadata (per-call overrides win).
  2. WolverineGrpcClientExceptionInterceptor — translates an incoming
    RpcException into a typed .NET exception using the shared
    WolverineGrpcExceptionMapper.MapToException table (the inverse of the
    server-side WolverineGrpcExceptionInterceptor). Streaming calls wrap each
    stream reader with a MappingStreamReader<T> so translation fires on
    MoveNext rather than the outer call.
  3. WolverineGrpcClientBuilder.ConfigureChannel — escape hatch to raw
    GrpcChannelOptions. Wolverine wraps, never replaces, the vendor API.

Ordering invariant (load-bearing). The exception interceptor is registered
first so it sits outermost in the call chain. When Polly or
AddStandardResilienceHandler() is composed on the same typed client, its
retry loop fires inside the RpcException catch — translating to a typed
exception before the retry loop would silently defeat retry semantics.
Documented on the interceptor and pinned by unit tests.

Unified surface, two substrates. AddWolverineGrpcClient<T>() auto-detects
the contract style and picks the right registration path:

  • Proto-first (concrete generated client class, e.g. Greeter.GreeterClient)
    → delegates to AddGrpcClient<T>(); WolverineGrpcClientBuilder.HttpClientBuilder
    is non-null so Polly, logging, and authentication handlers compose normally.
  • Code-first ([ServiceContract]-annotated interface for protobuf-net.Grpc)
    → uses an internal WolverineGrpcCodeFirstChannelFactory, because
    protobuf-net.Grpc does not ride on IHttpClientFactory; HttpClientBuilder
    is null in that case (callers who need Polly must use proto-first).

The delegate hook WolverineGrpcClientOptions.MapRpcException is a per-client
override consulted before the default table — returning null falls through.
PropagateEnvelopeHeaders (default true) toggles the propagation interceptor
off when an IMessageContext is resolvable but propagation is undesired.

Files: src/Wolverine.Grpc/Client/WolverineGrpcClientExtensions.cs,
WolverineGrpcClientOptions.cs, WolverineGrpcClientBuilder.cs,
WolverineGrpcClientPropagationInterceptor.cs,
WolverineGrpcClientExceptionInterceptor.cs,
WolverineGrpcCodeFirstChannelFactory.cs,
WolverineGrpcCodeFirstClientOptions.cs, plus a small
WolverineGrpcExceptionMapper.MapToException reverse-table addition. Guide
page: docs/guide/grpc/client.md.

Interface completion (CI fix)

M2 extended IMessageInvoker with StreamAsync<T>(object, MessageBus, …), but
four subscription-side implementers that don't inherit from MessageBus were
missed — surfaced by CI as CS0535 compile errors:

  • Wolverine.Marten.Subscriptions.InnerDataInvoker<T>
  • Wolverine.Marten.Subscriptions.NulloMessageInvoker
  • Wolverine.Polecat.Subscriptions.InnerDataInvoker<T>
  • Wolverine.Polecat.Subscriptions.NulloMessageInvoker

All four now implement StreamAsync<T> as throw new NotSupportedException(),
matching their existing InvokeAsync<T> semantics: subscriptions deliver events
one-way, so request/response invocation doesn't apply and streaming even less
so. Fast, expression-bodied throws — no iterator lowering, so the exception
surfaces on call rather than lazily on first MoveNextAsync.

Pinned with two guard tests (one per package) so a future refactor can't
silently relax the invariant.

Runnable samples (M10 + M11)

The canonical service + handler pairs live in proper sample projects under
src/Samples/, following the existing PingPong / PingPongWithRabbitMq
folder convention. Six sample trios, eighteen projects in total:

  • PingPongWithGrpc/{Messages,Ponger,Pinger} — code-first unary over Kestrel port 5001.
  • PingPongWithGrpcStreaming/{Messages,Ponger,Pinger} — code-first server streaming over 5002.
  • GreeterProtoFirstGrpc/{Messages,Server,Client} — proto-first (unary + streaming + fault mapping) over 5003.
  • RacerWithGrpc/{RacerContracts,RacerServer,RacerClient} — code-first bidirectional streaming
    over 5004, ported from the closed March PR. Client pushes IAsyncEnumerable<RacerUpdate>; server
    bridges each item through IMessageBus.StreamAsync<RacePosition> and yields back the full
    leaderboard — the recommended pattern until a first-class StreamAsync<TReq, TResp> lands.
  • GreeterWithGrpcErrors/{Messages,Server,Client} (M11) — code-first rich error details
    over 5005. Two RPCs cover the two supported paths: Greet surfaces
    FluentValidation.ValidationException as BadRequest with FieldViolations, and Farewell
    throws a domain GreetingForbiddenException that the server maps inline to PreconditionFailure
    via MapException<T>. The client demonstrates the intended read pattern —
    RpcException.GetRpcStatus() + Any.Unpack<T>() against BadRequest.Descriptor /
    PreconditionFailure.Descriptor. Smoke-tested end-to-end while writing the sample.
  • OrderChainWithGrpc/{Contracts,OrderServer,InventoryServer,OrderClient} (M14) — a
    Wolverine → Wolverine chain demonstrating the typed client end-to-end. An external
    (vanilla grpc-dotnet) client calls OrderServer; PlaceOrderHandler asks for
    IInventoryService by DI — it's registered via AddWolverineGrpcClient<IInventoryService>()
    in Program.cs — and calls inventory.Reserve(...) with no manual Metadata or CallOptions.
    The propagation interceptor stamps correlation-id / tenant-id / parent-id /
    conversation-id / message-id automatically; InventoryServer's handler reads the
    upstream correlation-id off its own IMessageContext and echoes it back on the reply, so the
    sample asserts the same correlation-id reached both hops. The failure path throws
    KeyNotFoundException from the inventory handler — it surfaces as
    KeyNotFoundException at the upstream handler's call site (client-side exception
    interceptor doing the inverse mapping), and the upstream server-side interceptor
    re-maps it to NotFound for the external caller. Four-project trio (the extra project
    is the second server) listening on 5006 (OrderServer) and 5007 (InventoryServer).

The test project takes ProjectReferences to the sample Messages + Server/Ponger projects
instead of duplicating the canonical types — handler + contract definitions are single-sourced.
GrpcTestFixture and ProtoFirstGrpcFixture declare the sample server assemblies as the
application assemblies for discovery. M11 kept the rich-errors fixture (RichErrorsCodeFirstFixture)
inside the test project on purpose: its contracts are deliberately minimal and focused on
exercising the full validation → BadRequest pipeline rather than doubling as a sample.

Package bump: Grpc.Core.Api 2.76.0 added to Directory.Packages.props — required because the
shared Messages assemblies with <Protobuf GrpcServices="Both"/> need ServerServiceDefinition,
ServiceBinderBase, and Method<TRequest,TResponse> at compile time for the generated code.


Public API surface

Member Purpose
AddWolverineGrpc() Registers interceptor, proto-first graph, codegen pipeline
MapWolverineGrpcServices() Discovers + maps code-first and proto-first services
WolverineGrpcServiceBase Optional base class exposing IMessageBus Bus
[WolverineGrpcService] Opt-in marker for non-GrpcService-suffixed classes
WolverineGrpcExceptionMapper.Map(ex) Public mapping table for custom interceptors
WolverineGrpcExceptionInterceptor Registered interceptor (exposed for diagnostics)
IMessageBus.StreamAsync<T>(request, ct) Streaming handler invocation
opts.UseGrpcRichErrorDetails(cfg => ...) Opt-in google.rpc.Status pipeline (M11)
opts.UseFluentValidationGrpcErrorDetails() ValidationExceptionBadRequest bridge (M11, separate package)
IGrpcStatusDetailsProvider Custom provider seam for building google.rpc.Status (M11)
IValidationFailureAdapter Plug-in point for validation-library-specific exception → FieldViolation mapping (M11)
GrpcRichErrorDetailsConfiguration.MapException<T>(code, factory) Inline shortcut for single-exception providers (M11)
GrpcRichErrorDetailsConfiguration.AddProvider<TProvider>() DI-resolved custom provider registration (M11)
GrpcRichErrorDetailsConfiguration.EnableDefaultErrorInfo() Opt-in opaque catch-all ErrorInfo (M11)
wolverine-diagnostics codegen-preview --grpc <service> CLI: preview generated proto-first gRPC wrapper code (M12)
MiddlewareScoping.Grpc New enum value for [WolverineBefore/After/Finally/OnException] to scope middleware exclusively to gRPC service chains (M13)
services.AddWolverineGrpcClient<T>(o => o.Address = ...) Typed gRPC client with envelope propagation + RpcException → typed-exception translation (M14)
WolverineGrpcClientOptions.{Address, PropagateEnvelopeHeaders, MapRpcException} Per-client configuration (M14)
WolverineGrpcClientBuilder.ConfigureChannel(Action<GrpcChannelOptions>) Escape hatch to raw channel options; .HttpClientBuilder exposes the underlying IHttpClientBuilder for proto-first clients (M14)
WolverineGrpcClientPropagationInterceptor / WolverineGrpcClientExceptionInterceptor Client-side interceptors — public for custom registration or diagnostics (M14)
WolverineGrpcExceptionMapper.MapToException(RpcException) Public inverse mapping table — symmetric to server-side Map(Exception) (M14)

Tests (78 green in Wolverine.Grpc.Tests)

  • Streaming handler support (M2): Acceptance/streaming_handler_support.cs — 6 tests covering happy path, cancellation, handler-side exceptions, middleware integration.
  • Code-first gRPC (M3+M5): code_first_grpc_tests.cs — unary round-trip, server-streaming round-trip, mid-stream cancellation, DI registration, exception-mapping theory (8 cases covering the full AIP-193 table).
  • Proto-first gRPC (M4+M5): proto_first_grpc_tests.cs — unary round-trip, multiple methods, generated-wrapper naming convention, server-streaming round-trip, mid-stream cancellation, exception-mapping theory (6 cases), codegen ordering regression, GrpcGraph registered on Options.Parts for CLI diagnostics; plus discovery unit tests for abstract-vs-concrete stub classification.
  • Exception mapping integration (M5): exception_mapping_integration_tests.cs — 8 unary cases plus a streaming-after-first-yield case.
  • OTel activity propagation (M6): otel_activity_propagation_tests.cs + proto-first equivalent — 3 cases asserting Wolverine activity TraceId matches the ASP.NET Core hosting activity. (Post-M10 this suite was hardened against a race between ActivityStopped and assertion reads by locking all list access and anchoring assertions on the messaging.message_type tag — parallel xUnit runs no longer see intermittent failures.)
  • Rich error details (M11): RichErrors/ — 10 tests across four files: DefaultErrorInfoProviderTests (3 — code emission, reason/domain tagging, no message/stack leakage), ValidationExceptionStatusDetailsProviderTests (3 — null-when-no-match, first-match-wins, multi-violation), InlineStatusDetailsProviderTests (2 — type-mismatch null, code + packed payload), and rich_error_details_code_first_tests (2 — end-to-end FluentValidation round-trip and valid-request passthrough via RichErrorsCodeFirstFixture).
  • Diagnostics CLI (M12): codegen_preview_grpc_tests (2 — end-to-end codegen against the GreeterGrpcHandler chain discovered from the proto-first sample, plus the unknown-input no-match path) in Wolverine.Grpc.Tests, plus 6 GrpcInputToFileName theory cases in CoreTests/Diagnostics/WolverineDiagnosticsCommandTests.cs (bare proto name, stub class name, already-normalized file name, case-preserving, whitespace trim).
  • Middleware scoping (M13): grpc_middleware_scoping_tests in Wolverine.Grpc.Tests — 4 tests asserting (1) GrpcServiceChain.Scoping == MiddlewareScoping.Grpc, (2) [WolverineBefore(MiddlewareScoping.Grpc)] applies via ChainExtensions.MatchesScope, (3) [WolverineBefore] (Anywhere) still applies, (4) [WolverineBefore(MiddlewareScoping.MessageHandlers)] no longer applies. All four run against a real GrpcServiceChain discovered from the Greeter proto-first sample.
  • Typed gRPC client (M14): Client/ — 19 tests across four files, all running against a live Kestrel HTTP/2 loopback in WolverineGrpcClientFixture.
    • registration_tests (7) — code-first [ServiceContract] interface classified as code-first, proto-first generated client class not classified as code-first, code-first registration returns a code-first builder (null HttpClientBuilder), proto-first registration exposes an IHttpClientBuilder, end-to-end code-first unary round-trip, end-to-end proto-first unary round-trip, missing Address throws a clear error at DI resolution time.
    • propagation_interceptor_tests (4) — stamps correlation-id + tenant-id when IMessageContext is in scope, stamps envelope-derived headers (parent-id / conversation-id / message-id) when the context carries an envelope, silently no-ops when no IMessageContext is resolvable, PropagateEnvelopeHeaders = false disables stamping per client.
    • exception_interceptor_tests (5) — unary RpcException → typed .NET exception translation theory (canonical AIP-193 codes), unmapped status code passes through the original RpcException, server-streaming RpcException after the first yield is translated per-MoveNext, per-client MapRpcException override takes precedence over the default table, override returning null falls through to the default table.
    • exception_mapper_reverse_tests (3) — MapToException theory for known codes mapping to idiomatic .NET exceptions, unmapped codes return the original RpcException, original RpcException preserved on the inner exception for diagnostics.
  • TestMessageContext.StreamAsync (coverage gap): TestMessageContextTests.cs — 2 cases covering the plain and DeliveryOptions overloads of the user-facing test-spy API.
  • Subscription invoker guards (CI fix): subscription_invoker_streaming_guard_tests.cs in MartenTests + PolecatTests — asserts NulloMessageInvoker.StreamAsync<T> throws NotSupportedException.

Documentation

Guide: docs/guide/grpc/ (multi-page section, own top-level sidebar entry gRPC Services):

  • index.md — rationale, getting started, runnable-samples callout, API reference table,
    Current Limitations, and a Roadmap section split into "Shipping in this PR"
    (MiddlewareScoping.Grpc, codegen-preview --grpc) and "Deferred to follow-up PRs"
    (ValidateStatus? convention, code-first codegen parity, hybrid handler shape) so
    contributors and consumers can plan against it.
  • handlers.md — the service → IMessageBus → handler flow; how gRPC handlers differ
    from HTTP/messaging and how OTel traces survive the hop.
  • contracts.md — code-first vs proto-first, side by side.
  • errors.md — full AIP-193 Exception → StatusCode table, opt-in google.rpc.Status
    pipeline (M11 wiring, validation path, domain-exception MapException, custom
    IGrpcStatusDetailsProvider, opt-in ErrorInfo catch-all, client read pattern, and
    the ~8 KB trailer-budget caveat).
  • streaming.md — server streaming, the bidirectional bridge pattern, cancellation.
  • samples.md — the five sample trios with pointers to the equivalent official
    grpc-dotnet examples for comparison.

docs/guide/samples.md lists the runnable gRPC sample trios under src/Samples/.
docs/guide/command-line.md documents the new codegen-preview --grpc flag with examples.

Vitepress sidebar entry: top-level gRPC Services section with five child pages.


Packages added / bumped

  • Grpc.AspNetCore 2.76.0 (new) — server hosting
  • Grpc.Net.Client 2.76.0 (new) — test client
  • Grpc.Tools 2.72 → 2.76 (bumped to match)
  • Grpc.Core.Api 2.76.0 (new, for sample Messages projects with GrpcServices="Both")
  • protobuf-net.Grpc 1.2.2 (new) — code-first contracts
  • protobuf-net.Grpc.AspNetCore 1.2.2 (new) — code-first server wiring
  • Google.Api.CommonProtos 2.16.0 (new, M11) — google.rpc.{Status,Code,BadRequest,ErrorInfo,PreconditionFailure}
  • Grpc.StatusProto 2.76.0 (new, M11) — RpcException.GetRpcStatus() / Status.ToRpcException() extensions

New NuGet packages published from this repo:

  • WolverineFx.Grpc — the integration package itself. (Renamed from the originally
    proposed WolverineFx.Http.Grpc per PR gRPC Support for Wolverine HTTP Endpoints + IMessageBus.StreamAsync<T> #2525 review — the package has no code
    dependency on Wolverine.Http; the old name only reflected the ASP.NET Core hosting
    relationship and was confusing. Project moved from src/Http/Wolverine.Http.Grpc/
    to src/Wolverine.Grpc/; tests moved in parallel.)
  • WolverineFx.FluentValidation.Grpc (M11) — opt-in bridge. Depends on
    WolverineFx.Grpc + WolverineFx.FluentValidation; carries no core surface.

Explicitly deferred

Called out in docs/guide/grpc/index.md under Current Limitations (hard blockers
today) and Roadmap (planned follow-ups):

Current Limitations

  • Client streaming and bidirectional streaming as a first-class adapter
    shape — blocked on request-side IAsyncEnumerable<TRequest> overloads on
    IMessageBus. Proto stubs with these shapes fail fast at startup rather than
    silently skipping. Code-first bidi is still achievable today by bridging each
    client item through Bus.StreamAsync<TResponse> in the service layer —
    demonstrated in the RacerWithGrpc sample.
  • User-configurable canonical exception table — the Exception → StatusCode
    mapping is static; a follow-up will make the fallback table pluggable. Note:
    this is orthogonal to the M11 rich-details pipeline, which is already
    user-configurable via MapException<T>, IGrpcStatusDetailsProvider, and
    IValidationFailureAdapter.

Roadmap (follow-up PRs)

  • Validate convention → Status? — HTTP handlers already support an opt-in
    Validate short-circuit; the gRPC equivalent would return Grpc.Core.Status?
    (or google.rpc.Status). Deferred because it lands cleanest on top of code-first
    codegen parity below.
  • Code-first codegen parity — proto-first services flow through GrpcServiceChain
    • the JasperFx codegen pipeline; code-first services (the WolverineGrpcServiceBase
      path) currently resolve dependencies via service location inside each method.
      Generating per-method code files for code-first services is the prerequisite for
      the Validate convention above and for tighter Lamar/MSDI optimization.
  • Hybrid handler shape (HTTP + gRPC + messaging on one type) — open design
    question around naming, scoping, and method-name conflicts. No concrete plan yet.
  • DataAnnotations ValidationException bridge — the IValidationFailureAdapter
    seam exists, but only the FluentValidation adapter ships in this PR. A
    DataAnnotations adapter is a drop-in follow-up (one class, no core changes).

Review guidance

  • IMessageBus.StreamAsync<T> is the one change to the Wolverine core surface;
    everything else is additive in new packages.
  • The abstract proto-stub requirement is a load-bearing design decision (the
    generated wrapper is the concrete implementation). The doc + fail-fast
    diagnostic are the two guardrails; please flag if you want a different shape.
  • M11 package split: WolverineFx.FluentValidation.Grpc is a separate
    package rather than a #if inside WolverineFx.Grpc, mirroring the
    WolverineFx.Http.FluentValidation / WolverineFx.Http split. If the
    preference is "one package with conditional deps" instead, the adapter is
    three files and trivial to fold in.
  • M11 marker vs. config-singleton: UseGrpcRichErrorDetails uses the same
    IServiceCollection.AddSingleton<Marker> idempotency pattern as
    UseFluentValidation, and the per-call GrpcRichErrorDetailsConfiguration
    builder is ephemeral (decomposed into registrations on exit). If you'd prefer
    a long-lived WolverineGrpcOptions sidecar instead, flag it — I picked this
    shape because it matched the 2026 method-on-config convention we've been
    standardising on.
  • M12 CLI flag placement: codegen-preview --grpc goes through the generic
    ICodeFileCollection DI seam rather than taking a compile-time dependency on
    Wolverine.Grpc from core. Same indirection --route uses for HTTP. Keeps
    Wolverine.csproj gRPC-package-free.
  • M13 enum ordinal stability: MiddlewareScoping.Grpc is appended last
    (ordinal 3) rather than inserted alphabetically, so MessageHandlers = 1
    and HttpEndpoints = 2 retain their existing values. Flag if you'd prefer
    alphabetical ordering despite the ordinal shift; this is a load-bearing
    choice for any attribute-value reflection/serialization paths downstream.
  • InternalsVisibleTo: Wolverine.Grpc exposes internals to
    Wolverine.Grpc.Tests, mirroring Wolverine.Http's
    AssemblyAttributes.cs. Needed so the M11 unit tests can reach the internal
    InlineStatusDetailsProvider<T> and GrpcRichErrorDetailsConfiguration.Registrations
    without promoting those to the public surface.
  • M14 client interceptor ordering: the exception interceptor is registered
    first on the client so it sits outermost in the call chain. When Polly /
    AddStandardResilienceHandler() is composed on the same typed client, the
    retry loop fires inside the RpcException catch — translating to a typed
    exception before retry would silently defeat retry semantics. Flag if you'd
    prefer the opposite ordering; doc + interceptor both carry the invariant.
  • M14 code-first substrate: proto-first clients go through Microsoft's
    AddGrpcClient<T>() (full IHttpClientFactory story); code-first
    ([ServiceContract] interfaces for protobuf-net.Grpc) use an internal
    WolverineGrpcCodeFirstChannelFactory because protobuf-net.Grpc does not
    ride on IHttpClientFactory. WolverineGrpcClientBuilder.HttpClientBuilder
    is null for code-first clients — documented, and the sample
    OrderChainWithGrpc exercises the code-first path end-to-end.

erikshafer and others added 8 commits April 16, 2026 15:35
Planning documents live in .plans/grpc-streaming/ and should not
leak into the eventual PR.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add IAsyncEnumerable<TResponse> StreamAsync<TResponse>() to ICommandBus
and implement across all IMessageInvoker implementors. Handlers that
return IAsyncEnumerable<T> are now streamed directly to the caller via
StreamAsync, and typed async enumerables returned from regular InvokeAsync
calls cascade each item individually (fixing a latent bug).

- Add StreamAsync<T> to ICommandBus, IMessageInvoker, MessageBus
- Implement Executor.StreamCoreAsync<T> with two-phase OTel span
- Fix EnqueueCascadingAsync to iterate typed IAsyncEnumerable<T>
  (previously fell through to PublishAsync on the sequence object)
- Add NotSupportedException for remote routes (MessageRoute, TopicRouting)
- Add WolverineTracing streaming constants + StartStreaming helper
- Add TestMessageContext.StreamAsync stubs (record + return empty stream)
- Add SendingEnvelopeLifecycle.StreamAsync delegation
- Add acceptance tests: stream items, empty, cancellation, cascade, options
Introduce WolverineFx.Http.Grpc package enabling Wolverine handlers to be exposed as ASP.NET Core gRPC services. Supports both proto-first (Grpc.Tools) and code-first (protobuf-net.Grpc) workflows with automatic service discovery, unary and server-streaming RPCs, and canonical exception mapping per Google AIP-193.

- Add GrpcGraph discovery for proto-first stubs marked [WolverineGrpcService]
- Add GrpcServiceChain code generation wrapping proto *Base classes
- Add WolverineGrpcExceptionInterceptor mapping .NET exceptions to gRPC StatusCodes
- Add WolverineGrpcServiceBase for code-first services forwarding to IMessageBus
- Add MapWolverineGrpcServices() endpoint convention for automatic registration
- Add integration tests covering unary, streaming, cancellation, and fault scenarios
- Add documentation guide explaining both styles and exception semantics
Verify that Wolverine handler activities chain under the ASP.NET Core
gRPC server hosting activity by sharing the same TraceId. Covers both
code-first and proto-first service styles, unary and server-streaming.

- Add WolverineActivityCapture test helper with global ActivityListener
- Add otel_activity_propagation_tests for code-first unary/streaming
- Add proto-first test case verifying same activity chain guarantee
- Sort DiscoverSupportedMethods() alphabetically for byte-stable codegen
- Add null-safe ParameterName() fallback for optimized assemblies
- Add observability section to docs explaining Activity.Current preservation
…kers

Subscription-side IMessageInvoker implementations (NulloMessageInvoker,
InnerDataInvoker) now reject StreamAsync calls with NotSupportedException.
Subscriptions deliver events one-way; streaming request/response semantics
are not applicable and must be blocked to prevent future refactors from
accidentally relaxing this invariant.

- Add StreamAsync override throwing NotSupportedException to NulloMessageInvoker (Marten + Polecat)
- Add StreamAsync override throwing NotSupportedException to InnerDataInvoker (Marten + Polecat)
- Add subscription_invoker_streaming_guard_tests pinning the contract
- Add FaultingStreamRequest handler and mid-stream fault acceptance test
- Add TestMessageContext.StreamAsync tests for recording + empty sequence● ★ Insight ─────────────────────────────────────
                                                                           - Why the 4 fixes are one-liners: Each subscription invoker already signaled "this isn't a bidirectional bus" by throwing NotSupportedException from InvokeAsync<T>. StreamAsync<T> extends exactly the same semantic — events arrive,
                                                                           nothing comes back. One throwing line keeps the types consistent without pretending streaming works.
                                                                           - Non-async throw from IAsyncEnumerable<T>: Because the body is an expression-bodied throw (not an iterator), we don't need async / yield. The method never returns an enumerable, so iterator lowering isn't triggered — the exception
                                                                           surfaces immediately when called, not lazily on first MoveNextAsync. That's the correct behavior for a guard: fail fast, not at the first iteration.
                                                                           - MartenTests.csproj has no explicit TargetFrameworks: multi-targeting net8/9/10 comes from a root-level props file (Directory.Build.props in src/Persistence or the repo root). Good to know for future test projects — don't duplicate
                                                                           the TFM list.
Relocate proto-first Greeter and code-first Ping/Pong gRPC sample applications from test project to dedicated samples folder. Add bidirectional streaming sample (RacerWithGrpc) demonstrating IAsyncEnumerable parameter and return. Includes appsettings.json for each server and consistent project structure across all samples.

- Move GreeterProtoFirstGrpc sample (proto-first unary + server-streaming + fault mapping)
- Move PingPongWithGrpc sample (code-first unary)
- Move PingPongWithGrpcStreaming sample (code-first server-streaming)
- Add RacerWithGrpc sample (code-first bidirectional streaming)
- Remove original test-embedded Ping/Greeter files from Wolverine.Http.Grpc.Tests
…ge type

WolverineActivityCapture.ActivityStopped fires on arbitrary threads (Wolverine runtime + ASP.NET pipeline), racing with assertion reads. Lock all list access to prevent concurrent modification. Anchor assertion on messaging.message_type tag to isolate the exact request activity pair in parallel xUnit runs, filtering out background work and other test collections.

- Add lock(_sync) around _all/_wolverine list mutations and snapshot reads
- Change WolverineActivities/AllActivities to lock-guarded IReadOnlyList<T>
- Rewrite AssertRequestActivityChainedUnderServerHostingActivity<T> to match on messaging.message_type tag
- Update all test call sites with typed message parameter
Implement AIP-193 compliant rich error details for Wolverine-backed gRPC services via grpc-status-details-bin trailer. Includes pluggable IGrpcStatusDetailsProvider chain, automatic BadRequest mapping for FluentValidation failures, and GreeterWithGrpcErrors sample demonstrating validation and domain exception flows.

- Add GrpcRichErrorDetailsConfiguration with MapException<T> inline builder
- Add IGrpcStatusDetailsProvider pluggable error detail contributor chain
- Add ValidationExceptionStatusDetailsProvider + IValidationFailureAdapter abstraction
- Add DefaultErrorInfoProvider opt-in catch-all for unmapped exceptions
- Add Wolverine.FluentValidation.Grpc package with FluentValidationFailureAdapter
- Add GreeterWithGrpcErrors sample (server + client + shared Messages project)
- Add UseGrpcRichErrorDetails() and UseFluentValidationGrpcErrorDetails() extensions
- Add integration tests covering inline provider, default ErrorInfo, and validation flow
@erikshafer
Copy link
Copy Markdown
Contributor Author

erikshafer commented Apr 17, 2026

Hmm. Adding support for Google's google.rpc.Status , AKA "Status" specification from AIP-193: https://google.aip.dev/193#error_model , added A LOT of file changes. Well, technically new files. Which ended up bleeding into creating WolverineFx.FluentValidation.Grpc . Before this addition, I think there were about 53 new/changed files. Oof.

However, it felt like it was worthwhile to give gRPC a similar treatment to HTTP with its FluentValidation capabilities and following the ProblemDetails specification.

It's also worth mentioning I have some ideas brewing for other uses of the StreamAsync<T> method on the IMessageBus .

The package has no dependency on Wolverine.Http — gRPC is a peer edge
protocol, not a sub-feature of the HTTP framework. Rename restores that
distinction and aligns with sibling packages (Wolverine.Kafka,
Wolverine.SignalR, Wolverine.FluentValidation.Grpc).

- Namespace: Wolverine.Http.Grpc -> Wolverine.Grpc
- NuGet id: WolverineFx.Http.Grpc -> WolverineFx.Grpc
- Folders promoted out of src/Http/ to src/Wolverine.Grpc{,.Tests}/
- Solution: new /Grpc/ folder siblings to /Http/ and /Persistence/
- Docs moved to docs/guide/grpc.md with its own sidebar section
- Dependents updated: Wolverine.FluentValidation.Grpc, 5 sample apps,
  root Wolverine InternalsVisibleTo entries
…rors, and samples

Document both code-first and proto-first gRPC integration styles with Wolverine, including contract declaration patterns, handler flow, error handling with AIP-193 support, and detailed sample breakdowns. Covers unary and server-streaming RPCs, OpenTelemetry propagation, and rich error details via google.rpc.Status.

- Add contracts.md explaining code-first vs proto-first decision matrix
- Add handlers.md detailing service→bus→handler flow and discovery
- Add errors.md covering default AIP-193 mapping + opt-in rich details
- Add samples.md comparing 5 sample apps to grpc-dotnet equivalents
- Document OpenTelemetry activity chain preservation in handlers.md
- Document ValidationException→BadRequest bridge in errors.md
…w flag, and deferred features

Add roadmap section to gRPC guide explaining what ships in the current PR vs. follow-up work. Covers MiddlewareScoping.Grpc behavior correction, codegen-preview --grpc flag for inspecting generated service chains, and deferred items: Validate convention returning Status?, code-first codegen parity with proto-first services, and hybrid handler shape design questions.

- Add roadmap section with "Shipping in this PR" vs "Deferred to follow-up PRs"
- Document MiddlewareScoping.Grpc as behavior correction (was MessageHandlers)
- Document codegen-preview --grpc / -g flag mirroring --handler / --route
- Defer Validate → Status? convention until code-first codegen lands
- Defer code-first per-method codegen parity with proto-first path
- Defer hybrid HTTP+gRPC+messaging handler shape (open design question)
Extend `wolverine-diagnostics codegen-preview` to preview generated gRPC service wrapper code via `--grpc <service>` flag. Accepts proto service name (`Greeter`), stub class name (`GreeterGrpcService`), or file name (`GreeterGrpcHandler`). Mirrors existing `--handler` and `--route` workflow.

- Add GrpcFlag property and -g short alias to WolverineDiagnosticsInput
- Add PreviewGrpcCode() searching ICodeFileCollection for gRPC chains
- Add GrpcInputToFileName() normalizing input to expected file name
- Add codegen_preview_grpc_tests end-to-end coverage in Wolverine.Grpc.Tests
- Update docs/guide/command-line.md with gRPC preview examples
Change `GrpcServiceChain.Scoping` from `MessageHandlers` to `Grpc` so that middleware explicitly scoped to `[WolverineBefore(MiddlewareScoping.Grpc)]` attaches to gRPC chains and `[WolverineBefore(MiddlewareScoping.MessageHandlers)]` no longer inadvertently applies.

- Add MiddlewareScoping.Grpc enum value for proto-first gRPC service chains
- Change GrpcServiceChain.Scoping property to return MiddlewareScoping.Grpc
- Add grpc_middleware_scoping_tests verifying scope matching behavior
Add `AddWolverineGrpcClient<T>()` to roadmap as an adoption-driven convenience layer over `Grpc.Net.ClientFactory`. Plans correlation-id/tenancy/message-id metadata propagation, `RpcException` → typed-exception client interceptor mirroring `WolverineGrpcExceptionInterceptor`, and `DeliveryOptions`-style header plumbing. Raw `GrpcChannel` + generated stubs remain fully supported.
…ption translation

Implement `AddWolverineGrpcClient<T>()` extension providing correlation/tenant/message-id header propagation and `RpcException` → typed .NET exception translation. Supports both code-first (`[ServiceContract]`) and proto-first (generated `*Client`) contracts through unified API. Includes per-client `MapRpcException` override, `ConfigureChannel` escape hatch, and `PropagateEnvelopeHeaders` opt-out.

- Add WolverineGrpcClientExtensions with AddWolverineGrpcClient<T> registration
- Add WolverineGrpcClientPropagationInterceptor stamping envelope headers from IMessageContext
- Add WolverineGrpcClientExceptionInterceptor translating RpcException per AIP-193 table
- Add WolverineGrpcExceptionMapper.MapToException inverse mapping (client-side)
- Add WolverineGrpcCodeFirstChannelFactory for code-first contract substrate
- Add IHeaderEchoService test contract + HeaderEchoGrpcService test endpoint
- Add propagation_interceptor_tests, exception_interceptor_tests, registration_tests
- Add docs/guide/grpc/client.md covering registration, propagation, error translation
…typed-exception round-trip across Wolverine gRPC services
…mand-line support

Add comprehensive README documentation for all five gRPC samples (GreeterProtoFirstGrpc, OrderChainWithGrpc, PingPongWithGrpc, PingPongWithGrpcStreaming, RacerWithGrpc) covering architecture, running instructions, and expected output. Enable `RunJasperFxCommands()` on all sample servers for consistency with existing samples. Standardize framework flag usage across GreeterWithGrpcErrors sample.

- Add README.md files to GreeterProtoFirstGrpc, OrderChainWithGrpc, PingPongWithGrpc, PingPongWithGrpcStreaming, and RacerWithGrpc samples
- Replace app.Run() with RunJasperFxCommands(args) in all sample server Program.cs files
- Add --framework net9.0 flag to GreeterWithGrpcErrors running instructions for consistency
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants